Skip to content

fix: stop dropping the variables a style's paints and effects bind - #166

Merged
awdr74100 merged 5 commits into
mainfrom
fix/style-variable-bindings
Aug 22, 2026
Merged

fix: stop dropping the variables a style's paints and effects bind#166
awdr74100 merged 5 commits into
mainfrom
fix/style-variable-bindings

Conversation

@awdr74100

Copy link
Copy Markdown
Owner

Fixes #164.

What was wrong

Figma does not keep a paint's or effect's variable binding in the owning node's
boundVariables — it keeps it on the object itself. serializePaint,
serializeEffect and serializeLayoutGrid each picked a fixed set of fields and
never read it, so a value the designer bound to a variable came back as a plain
literal, indistinguishable from a hard-coded one. get_styles also never read a
TextStyle's own boundVariables, which is the only place a bound
fontSize / lineHeight / letterSpacing exists.

The report names an effect colour; the same drop covered:

where fields
SolidPaint color — every paint style and every node's fills/strokes
ColorStop color — each gradient stop binds independently
Effect color, radius, spread, offsetX, offsetY
LayoutGrid sectionSize, count, offset, gutterSize
TextStyle the eight typography fields

What changed

  • Each of those objects now carries boundVariables ({ field: variableId }),
    optional, emitted only when something is actually bound.
  • get_styles returns a variables table (id → { name, type, codeSyntax? }),
    the same shape get_design_context already returns, leading the result so a
    reader meets an id already knowing what it names. Ids stay the key because
    variable names collide across collections (a local and a library primary).
  • A drift ratchet asserts the serializer accounts for every VariableBindable*Field
    family the typings declare, so a future Figma release that makes a new kind of
    object bindable fails CI instead of silently repeating this bug.

Deliberately not done

  • Style-level boundVariables (PaintStyle.paints / EffectStyle.effects /
    GridStyle.layoutGrids) is not read. Measured against a live file it is a flat
    VariableAlias[] of whatever is bound somewhere in the array — it names neither
    which paint/effect nor which field — a strictly lossy summary of what the
    per-object bindings now carry exactly.
  • get_design_context's globalVars bundles stay unchanged: that is the
    budget-constrained hot path, a node's own boundVariables already names every
    variable its fills/strokes/effects reference (measured: populated both for a
    binding made on the node and for one inherited from a shared style), and all the
    bundle would add is which field of which paint. Pinned by tests.
  • The write side. SerializedPaint / SerializedEffect / SerializedLayoutGrid
    are bidirectional, and the write tools ignore boundVariables — documented on the
    schema, with a figma-build rule against writing a literal back over a bound value.
    Round-tripping a binding through set_* / update_* is a separate change.

Measured on a real design system

Run against a production file after the fix, its 23 text styles came back with
69 bindings that were invisible before — every one binds fontSize,
fontStyle and fontFamily — resolved to 15 distinct tokens (size/5xl,
weight/Bold, Font family, …). Two things that file settles:

  • Library variables resolve. Ten of the fifteen ids are remote
    (VariableID:<key>/<id>), and getVariableByIdAsync names them — so
    get_styles can name tokens get_variable_defs, which returns local
    variables only, cannot.
  • The reported case, reproduced and fixed end to end. A drop shadow whose
    colour and blur were bound in the Figma UI and saved as an effect style now
    returns boundVariables: { color: "VariableID:…", radius: "VariableID:…" }
    next to the literal, with both ids named in variables — one of them a library
    variable carrying the designer's own codeSyntax (var(--sds-size-icon-small)),
    so the consumer gets the project-side token name without a heuristic join.
  • Per-object beats the node's own list, measured. The same node reports
    boundVariables: { effects: [id1, id2] } — two ids, no indication which is the
    colour and which is the radius. Only the per-effect map says that. It is the same
    reason the style-level list is not read.
  • Names collide, which is why the id stays the key. size/2xl appears twice
    (a local VariableID:1:1253 and a library VariableID:3ad2…/675:134), as does
    weight/Regular. Inlining names into the bindings would have merged two
    different tokens.

Verification

  • Raw Figma shapes measured live through the plugin API (not inferred) and used as
    the test fixtures — including the detail a hand-written fixture would never guess:
    an unbound paint carries boundVariables: {}, which must not become an empty
    field in the payload.
  • Differential run against the previous serializer: content with no bindings is
    byte-identical; bound content only ever gains a boundVariables key, with
    no existing value changed or removed.
  • 18 mutations of the new logic, each confirmed to fail at least one test.
  • Every commit builds and passes the full suite on its own.
  • Live through the built plugin, on a real design file: a bound node fill, a bound
    shadow (colour + radius) both inline on a node and saved as an effect style, the
    23 text styles, the variables table over local and library variables, and
    codeSyntax. Gradient-stop and layout-grid bindings are covered by unit tests
    against probe-measured shapes rather than end to end — no tool can create either
    binding, so they need a hand-made one in the Figma UI.

…n the wire

Figma does not keep a fill or shadow colour's variable binding in the owning
node's `boundVariables` — it keeps it on the paint, the gradient stop, the
effect or the layout grid itself, and a text style keeps its typography
bindings on the style (its values are scalars, so there is no per-object level
to hang them on). None of those had a place in the wire shapes.

`SerializedBindings` is `field -> variable id`, optional everywhere it appears,
so anything unbound serializes exactly as before. Field names pass through as
Figma reports them rather than being filtered against a hard-coded list: a
newly bindable field then rides along instead of being silently dropped.

`GetStylesResult` gains the `variables` id -> token table get_design_context
already returns, so a binding is readable without a second round trip. Names
are not inlined into the bindings: variable names collide across collections
(a local and a library `primary`), so the id stays the key.
…grid binds

serializePaint / serializeEffect / serializeLayoutGrid each picked a fixed set
of fields and never read `boundVariables`, so a value the designer bound to a
variable came back as a plain literal — indistinguishable from one that was
hard-coded. That is every SOLID paint's colour, every gradient stop's colour,
a shadow's colour / radius / spread / offsetX / offsetY, and a layout grid's
sectionSize / count / offset / gutterSize, on both style and node reads.

The fixtures are the raw shapes a live file actually returns, measured against
the plugin API rather than inferred — including the one a hand-written fixture
would never guess: an UNBOUND paint carries `boundVariables: {}`, which must
not turn into an empty field in the payload.

get_design_context is deliberately left alone: its globalVars bundles are the
budget-constrained hot path, a node's own `boundVariables` already names every
variable its fills / strokes / effects reference (measured: populated both for
a binding made on the node and for one inherited from a shared style), and all
the bundle would add is which field of which paint. Pinned by tests so the
boundary cannot flip by accident, including that a bound and an unbound copy
of the same colour still share one bundle.
…y point at

Closes the reported half of the miss: an effect style whose shadow colour is
bound to a variable came back as `color: {r,g,b,a}` with nothing saying it was
a token, so a consumer could not tell a genuinely hard-coded shadow from one
that merely serializes to its variable's current value.

Paint / effect / grid styles get theirs from the serializer now. A text style
needs its own read: its values are scalars, so `TextStyle.boundVariables` is
the only place a bound fontSize / lineHeight / letterSpacing exists.

The style-level `boundVariables` those three style kinds also expose is
deliberately not read. Measured against a live file it is a flat
`VariableAlias[]` of whatever is bound somewhere in the style's array — it
names neither which paint/effect nor which field — so it is a strictly lossy
summary of what the per-object bindings now carry exactly.

Referenced ids resolve to `{ name, type, codeSyntax? }` in a `variables` table,
mirroring get_design_context. It leads the result so a reader meets
`VariableID:5:12` already knowing what it names, is omitted when the document
binds nothing, and is assembled in walk order rather than as the parallel
lookups settle — otherwise the same document could serialize two different
byte sequences on two runs.
Missing one of these bindings is invisible: the payload still carries a
perfectly good literal, it has just quietly stopped saying the value is a
token. That is how the reported bug survived — and the serializer's field
lists have no compile-time coupling to the typings, so nothing would catch the
next one.

`plugin-api.d.ts` names every bindable surface with a `VariableBindable*Field`
alias, which makes that list the authoritative inventory. Recording it turns
"Figma made a new kind of object bindable" into a CI failure on the typings
bump, the one moment someone is looking. Fields inside an existing family need
no entry — the serializer passes field names through, so a new one rides along.
…a literal

get_styles now shows which of a style's values are references rather than
literals, which makes the failure it enables worth naming: re-syncing a style
ramp from code through update_* writes the resolved number or colour back over
the binding, and the style silently stops tracking the token it was built on.
Change the variable instead.
@awdr74100
awdr74100 merged commit 51dca11 into main Aug 22, 2026
4 checks passed
@awdr74100
awdr74100 deleted the fix/style-variable-bindings branch August 22, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

get_styles does not preserve variable bindings for effect colors

1 participant